在 Svelte 中我們要管理 side-effect 的行為通常就會使用 $effect
這個 rune ,首先我們先來看一個簡單的範例
先做個名詞定義,以下提到 effect 通常是指要管理 side effect 也就是
$effect
裡的()⇒{}
這個 function 裡面的東西,而$effect
指的是這個 rune
<script lang="ts">
let obj = $state({ value: 0 });
let derivedObj = $derived({ value: obj.value * 2 });
$effect(() => {
console.log(`[Effect 1] obj.value: ${obj.value}`);
});
$effect(() => {
console.log(`[Effect 2] obj: ${obj}`);
});
$effect(() => {
console.log(`[Effect 3] derivedObj: ${derivedObj}`);
});
</script>
<button onclick={() => (obj.value += 1)}> Increment obj.value </button>
<button onclick={() => (obj = {...obj,value: obj.value + 1})}> Increment obj.value (immutable)</button>
<p class="content">{obj.value} doubled is {derivedObj.value}</p>
從上面的影片我們可以看出四個小細節
$effect
在第一次渲染時會先執行一次
$effect
會自動追蹤依賴,有使用到的 $state
或者 $derived
更新時會再次執行
如果是變更其中一個 property 的話,對於${obj}
這種使用整個 object 的形式並不會有 reactive
如果是 immutable update 因為是整個 object 被替換掉所以 [Effect 2]
可以被觸發到
這裡為了方便示範,先把倒數計時的部分抽成一個 component,而在 $effect
中 return
的 function 就是所謂的 cleanup function。
執行的時機為下次 effect 執行前會先執行一次 cleanup 以及 component destroy (unmounted) 前會先執行。
<!-- in Counterdown.svelte -->
<script lang="ts">
let count = $state(0);
$effect(() => {
console.log('Starting interval');
const id = setInterval(() => {
count += 1;
}, 1000);
return () => {
console.log('Clearing interval');
clearInterval(id);
};
});
</script>
{count}
再次說明 Svelte 中每個 .svelte 的檔案都是一個 component
<!-- in +page.svelte -->
<script lang="ts">
import Countdown from './Countdown.svelte';
let showCountdown = $state(false);
</script>
<div>
<button onclick={() => (showCountdown = !showCountdown)}>
{showCountdown ? 'Hide' : 'Show'} Countdown
</button>
</div>
{#if showCountdown}
<Countdown />
{/if}
而要使用 component 就是
import
該 .svelte 的default export
,至於其他關於 component 的細節就留待以後文章再來說明。
從影片中看得出來,第一次 render 時會有 Starting interval
而當我按下按鈕後就會將 <Countdown />
destroy 也就觸發了 cleanup function
有些讀者或許會想問為什麼 console.log('Starting interval');
沒有每秒都被執行,回到前面例子的我們提到的「$effect
會自動追蹤依賴,有使用到的 $state
或者 $derived
更新時會再次執行」。
但在這個例子中因為我沒有在 $effect
的 function 中直接使用到 count
而是只有在 setInterval
的 callback 中使用,所以並不會被 $effect
自動追蹤到依賴。
在 Svelte 我們是能將 effect 的執行時機提前在 DOM 更新前的,就是使用 $effect.pre
這個 rune。
<!-- in Counter.svetle -->
<script lang="ts">
let obj = $state({ value: 0 });
let derivedObj = $derived({ value: obj.value * 2 });
let p: HTMLParagraphElement| null = $state(null);
$effect.pre(() => {
console.log(
'\x1b[36m%s\x1b[0m',
`[Pre Effect]\n`,
`p.innerText: ${p?.innerText} \n obj.value: ${obj.value}`
);
return () => {
console.log(
'\x1b[36m%s\x1b[0m',
`[Pre Effect Cleanup]\n`,
`p.innerText: ${p?.innerText} \n obj.value: ${obj.value}`
);
};
});
$effect(() => {
console.log('\x1b[32m%s\x1b[0m', `[Effect 1]\n`, `obj.value: ${obj.value}`);
return () => {
console.log('\x1b[32m%s\x1b[0m', `[Effect 1 Cleanup]\n`, `obj.value: ${obj.value}`);
};
});
</script>
<button onclick={() => (obj.value += 1)}> Increment obj.value </button>
<button
onclick={() =>
(obj = {
...obj,
value: obj.value + 1
})}
>
Increment obj.value (immutable)</button
>
<p class="content" bind:this={p}>{obj.value} doubled is {derivedObj.value}</p>
'\x1b[36m%s\x1b[0m'
之類的東西是將 console 的字上色用的
這邊我們先用
bind:this
這個 directives 來輔助說明,簡單來說它的功用就是可以獲得 DOM node 。
<!-- in +page.svelte -->
<script lang="ts">
import Counter from './Counter.svelte';
let showCounter = $state(true);
</script>
<div>
<button onclick={() => (showCounter = !showCounter)}>
{showCounter ? 'Hide' : 'Show'} Counter
</button>
</div>
{#if showCounter}
<Counter />
{/if}
我們可以發現重新整理後會先出現 [Pre Effect]
然後才是 [Effect 1]
,這是因為 $effect.pre
是會在 DOM 更新前先行觸發,而此刻 <p>
還沒出現在 DOM 上,所以 p.innerText
會是 undefined
而 obj.value
是 0
是因為這個 state 的預設值是 0
[Pre Effect]
p.innerText: undefined
obj.value: 0
然後 DOM 更新完後 $effect
觸發了
[Effect 1]
p.innerText: 0 doubled is 0
obj.value: 0
因為對於 $effect.pre
來說 p?.innerText
從 undefined
變成有值了所以會再次觸發 $effect.pre
,但在執行 $effect.pre
的 effect 前會先執行它的 cleanup。
[Pre Effect Cleanup]
p.innerText: 0 doubled is 0
obj.value: 1
[Pre Effect]
p.innerText: 0 doubled is 0
obj.value: 1
所以初次渲染最後順序才會是 [Pre Effect]
→ [Effect 1]
→ [Pre Effect Cleanup]
→ [Pre Effect]
接下來按了按鈕狀態更新後,在因為 obj.value
更新了所以 DOM 更新前會先執行 $effect.pre
[Pre Effect Cleanup]
p.innerText: 0 doubled is 0
obj.value: 1
[Pre Effect]
p.innerText: 0 doubled is 0
obj.value: 1
每次 effect 執行前會先執行它的 cleanup
之後 DOM 更新完成了,就開始執行 $effect
[Effect 1 Cleanup]
p.innerText: 1 doubled is 2
obj.value: 1
[Effect 1]
p.innerText: 1 doubled is 2
obj.value: 1
然後將 Counter
destroy 就觸發了 $effect
以及 $effect.pre
的 cleanup
[Pre Effect Cleanup]
p.innerText: 1 doubled is 2
obj.value: 1
[Effect 1 Cleanup]
p.innerText: 1 doubled is 2
obj.value: 1
之後再 mount 一次就會跟第一次掛載的順序一樣。
$effect
它是在 component 在 DOM 上渲染後執行,並會在使用到 $state
或$derived
的值更新後再次重新執行
$effect.pre
也是在使用到 $state
、 $derived
的值更新後重新執行,只是執行時機是在 DOM 渲染前
cleanup 是會在每次 effect 執行前先執行以及會在 destroy (unmount) 時執行
雖然不是每個狀態的變更都會重新渲染 DOM ,所以
$effect
和$effect.pre
用 DOM 渲染前後作為分界點只是為了好想像。
明天將會繼續說明 $effect
的其他行為以及把 rune 這個篇章做個收尾,原本以為 rune 是一天就講得完,但深入研究一下才發現 $effect
比想像中的複雜很多QQ
https://github.com/toddLiao469469/30days-for-svelte5/tree/main/src/routes/day05
自己是前端小白,
原本side project用Vanilla JS寫,結果隨著功能越加越多,程式碼相當繁瑣,已經難以trace code,這幾天驚覺是時候使用框架了,但是又不想用React來寫,本來是先想到Vue,但是稍微看了一下官方文檔,實在不太喜歡Vue把if、else、for這些邏輯寫在element的attribute裡,所以就決定試試看Svelte,查了發現最近正式release了第5版,我大概把官方tutorial的Basic Svelte做了一遍,覺得寫法比React簡潔許多,還有雙向綁定,而且CSS的作用只會侷限在該Component裡實在太方便,解決了樣式汙染和一定程度減少class的命名焦慮,但是Svelte的state和effect怎樣可以reactive我還沒有搞得很清楚,如果又搭配global的shared state、store和子元件變更props的值,整個行為又更複雜,加上我又是Svelte超級小白,搞的我頭昏腦花,不像React就是單向數據流,要更改state就用set function,子元件要更改父元件的state就將set function傳遞給子元件就好,只要更改state,甚至是state裡面的任何一個property,基本上就是整個component連同他的所有子孫全部重新執行和渲染,行為和數據流單純,不太需要思考數據同步的問題,effect的話我自己也比較偏好React要表明dependency,可以一眼知道這個effect執行的依賴條件,而且也比較容易控制和調整,Svelte給Compiler自己判定dependency雖然看起來好像很方便,也能用untrack來表明不要依賴此變數,但如果effect裡面邏輯很複雜有許多變數的話,且又難以馬上辨別出哪些是state、derived或props,感覺就會變成是一個災難。
以上都是我短短幾天使用Svelte的個人感想和心得,有些問題可能是因為我還不熟悉所以才有的,也因為用Svelte時遇到了這些問題,才開始找外部或社群有沒有相關的問題和教學,沒想到鐵人賽馬上就有,而且還很完整詳細,目前正在學習中,真是感恩!
這段程式碼應該不小心誤植
<button onclick={() => (obj.value += 1)}> Increment obj.value (immutable)</button>
應該改為下段程式碼才是immutable
<button onclick={() => (obj = { ...obj, value: obj.value + 1 })}> Increment obj.value (immutable) </button>
意外發現,如果effect使用JSON.stringify(obj)
或Object.keys(obj)
,就算只修改property,都會觸發effect,然後getValue(obj)
表面上是傳入整個obj,但function內部會拜訪obj.value
,所以如果有變更到value的話一樣會觸發effect,還有如果連續按set obj.count = 0,會發現只有第一次有觸發effect,感覺詳細的effect觸發的機制好像可以整理成以下:
JSON.stringify(obj)
和Object.keys(obj)
因為會訪問所有property,所以只要任意property的value發生變化,就會觸發)感想: Svelte effect 觸發機制真的很複雜,而且還有nested的object和multidimesional arrays,甚至是更多層的混搭 ...
程式碼如下
<script>
let obj = $state({ value: 0 });
let array = $state([0])
function getValue(obj){
return obj.value
}
$effect(() => {
console.log(`[Effect 1] obj: ${obj}`);
});
$effect(() => {
console.log(`[Effect 2] obj: ${JSON.stringify(obj)}`);
});
$effect(() => {
console.log(`[Effect 3] obj: ${Object.keys(obj)}`);
});
$effect(() => {
console.log(`[Effect 4] obj: ${getValue(obj)}`);
});
$effect(() => {
console.log(`[Effect 5] array: ${array}`);
});
$effect(() => {
console.log(`[Effect 6] array: ${array[0]}`);
});
$effect(() => {
console.log(`[Effect 7] array: ${array[1]}`);
});
</script>
<p>{JSON.stringify(obj)}</p>
<p>{array.join(", ")}</p>
<button onclick={() => {obj.value += 1}}> Increment obj.value </button>
<button onclick={() => {obj.count = 0}}> set obj.count = 0 </button>
<button onclick={() => {obj = { value: 0 }}}> reset obj </button>
<button onclick={() => {array.push(array.length)}}> push array new element </button>
<button onclick={() => array[0]++ }> increase array[0]</button>
<button onclick={() => array[1]++ }> increase array[1]</button>
<button onclick={() => array = [0]}> reset array</button>
非常感謝您勘誤~
$effect
的機制確實有點複雜,但我想只要知道這個 effect 是用到該 state 的哪些部分應該就比較能夠了解整個流程了。
這點其實雖然說很方便但其實不太好預測結果,因為直覺來說(至少寫習慣的 React 的人的直覺)都會想說 object 直接更新特定 property 並不會改變 object 本身的 reference ,所以並不會觸發更新。
但我覺得大多數情況下開發者不必太在意這點,畢竟有用到的 property 部分通常都會是我們想要監聽的 property,只是 Svetle 多幫我們做到監聽 property 更新
至於 $effect
如果有複雜的邏輯會不會很難維護這件事情,我覺得或許一開始就不要有 $effect
可能會比較好。
至少在我自己的開發經驗,不管是 $effect
或是 React 的 useEffect
大部分時間可能都可以被取代,可能是在某些事件 callback中執行原本想要執行的 effect 或者用 derived 就能取代某些狀態同步的功能